소유권과 참조 (Ownership & Borrowing)

NOTE

Rust의 소유권(Ownership), 이동(Move)/클론(Clone)/대여(Borrow) 차이, 참조(&)와 C 포인터의 비교, 자동 역참조, Raw Pointer까지 정리.

📌 개념

1. 소유권(Ownership)과 함수 매개변수

Rust에서는 변수가 데이터의 소유권(Ownership)을 가진다. 함수에 변수를 전달할 때 타입 정의에 따라 동작이 달라진다.

fn main() {
    let test = String::from("Hello");
    // 1. 소유권 이동 (Move)
    read_test_move(test);
    // println!("{test}"); // ❌ 에러! test의 소유권이 넘어갔으므로 접근 불가 (E0382)
 
    // 2. 소유권 대여 (Borrow / 참조)
    let test2 = String::from("World");
    read_test_borrow(&test2);
    println!("{test2}"); // ✅ 정상 작동! 소유권이 main에 남아있음
}
 
fn read_test_move(parameter: String) {
    println!("{parameter}");
} // parameter가 스코프를 벗어나며 메모리(Heap)에서 해제(drop)됨
 
fn read_test_borrow(parameter: &str) { // Idiomatic: &String보다 &str 사용 권장
    println!("{parameter}");
}

2. 이동(Move) vs 클론(Clone) vs 참조(&)

String처럼 힙(Heap) 메모리를 사용하는 타입의 데이터 복사/전달 방식 차이.

구분소유권 변화힙(Heap) 메모리 동작비고 / 성능
Move (let b = a)a → b 이동메모리 재할당 없음 (Stack의 포인터 정보만 복사 후 a 무효화)오버헤드 없음 (기본 동작)
Clone (let b = a.clone())a, b 각각 소유깊은 복사(Deep Copy) 발생 (힙 메모리 새로 할당 및 데이터 전체 복사)힙 할당 비용 발생
Borrow (let b = &a)a 유지 (b는 빌림)메모리 재할당 없음 (주소값만 전달)가장 효율적 (추천)

3. 참조(&)와 대여(Borrowing) — 공식 규칙

**참조자(Reference)**는 해당 주소에 저장된 데이터에 접근할 수 있게 해주는 주솟값으로, 포인터와 유사하지만 “살아있는 동안 특정 타입에 대한 유효한 값을 가리킴을 보장”한다. & 연산자로 만들며, 값의 소유권을 가져오지 않고 참조하는 행위를 대여(borrow) 라고 한다.

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // 참조자 전달
    println!("'{}'의 길이: {}", s1, len); // s1을 계속 사용 가능
}
 
fn calculate_length(s: &String) -> usize {
    s.len()
}

불변 참조자 (기본값): 빌린 값은 수정 불가.

fn change(some_string: &String) {
    some_string.push_str(", world"); // ❌ 컴파일 에러 (cannot borrow as mutable)
}

가변 참조자 (&mut): 값을 수정하려면 명시적으로 가변 참조를 사용.

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}
 
fn change(some_string: &mut String) {
    some_string.push_str(", world"); // ✅ 정상 작동
}

참조자의 두 가지 황금 규칙

  1. 특정 스코프 내에서 하나의 가변 참조자(&mut) 또는 여러 개의 불변 참조자(&) 중 하나만 가질 수 있다 (동시 사용 불가).
  2. 참조자는 항상 유효해야 한다 (댕글링 참조 금지).
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // ❌ E0499: 가변 참조자 2개 동시 불가
 
let r3 = &s;
let r4 = &mut s; // ❌ E0502: 불변 참조와 가변 참조 혼용 불가

이 제약은 데이터 경합(Data Race) — ① 둘 이상의 포인터가 동일 데이터에 접근, ② 하나 이상이 쓰기 작업 수행, ③ 동기화 메커니즘 부재 — 를 컴파일 타임에 원천 차단하기 위함이다.

NLL (Non-Lexical Lifetimes): 참조자는 정의 지점부터 “마지막으로 사용된 지점”까지만 유효하다. 따라서 아래처럼 불변 참조 사용이 끝난 뒤 가변 참조를 만드는 것은 허용된다.

let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // r1, r2의 마지막 사용 지점
 
let r3 = &mut s; // ✅ 정상: r1, r2는 이미 사용이 끝남
println!("{}", r3);

댕글링 참조(Dangling Reference) 방지: 함수가 지역 변수의 참조를 반환하려 하면 컴파일 에러가 발생한다.

fn dangle() -> &String {
    let s = String::from("hello");
    &s // ❌ 에러: s가 함수 끝에서 drop되므로 참조가 무효해짐
}
 
fn no_dangle() -> String {
    let s = String::from("hello");
    s // ✅ 정상: 참조 대신 소유권 자체를 이전
}

4. Rust의 참조(&) vs C언어의 포인터

참조(&)의 내부 동작은 C언어의 포인터(주소값 전달)와 동일하지만, 컴파일 타임 안전성에서 차이가 난다.

[Stack]                           [Heap]
test2 (String) ──(포인터/길이)──> "World"
parameter (&str) ─(주소값 참조)──┘
  • 공통점: 8바이트(64bit 기준) 주소값만 전달하므로 힙 복사 없이 빠름.
  • Rust만의 차이점 (컴파일러 검증)
    1. Null Pointer 없음 — 참조는 항상 유효한 메모리를 가리킴.
    2. Dangling Pointer 없음 — 원본 데이터가 해제되면 참조도 컴파일 불가능 (위 3번 항목).
    3. Data Race 방지 — 불변 참조(&T)는 여러 개 가능하지만, 가변 참조(&mut T)는 단 1개만 허용.

5. . 연산자와 자동 역참조 (Auto-Deref)

C언어에서는 포인터 접근 시 -> 화살표 연산자를 사용하지만, Rust는 원본이든 참조(&)든 모두 .(점) 연산자를 사용한다.

struct Point { x: i32, y: i32 }
 
let p = Point { x: 10, y: 20 };
let p_ref = &p;
 
println!("{}", p.x);     // 10
println!("{}", p_ref.x); // 10 (컴파일러가 (*p_ref).x 로 자동 역참조)

Rust에서 ->는 오직 함수의 반환 타입 지정(fn add() -> i32)에만 쓰인다. C의 화살표 역참조와 혼동하지 않도록 주의.

6. 포인터 주소값 및 값 직접 다루기

목적방법예시
주소값 자체 출력 포맷팅println!("{:p}", r); → 0x7ff7bfe0f8f4
포인터가 가리키는 값 변경* 역참조 연산자let mut num = 10; let r = &mut num; *r += 5;
C언어 스타일 날 포인터Raw Pointer (*const T, *mut T)let raw = &num as *const i32; unsafe { println!("{}", *raw); }

Raw Pointer는 Rust의 안전성 보장(Null/Dangling/Data Race 방지)을 받지 않으므로, 역참조 시 반드시 unsafe 블록이 필요하다.

NOTE

“Rust에는 명시적 return이 없다?”는 표현식 기반 반환은 별도 노트로 분리했다 — (Rust) 표현식과 문장(Expression vs Statement, 암묵적 반환) - 핵심 개념 및 특징 정리 참고.

7. mut과 &mut은 서로 다른 축이다

가장 헷갈리기 쉬운 지점. mut은 소유권과 아무 관련이 없다.

  • 소유권 이동 여부 = &가 붙었느냐 안 붙었느냐
  • 수정 가능 여부 = mut이 붙었느냐 안 붙었느냐
소유권을 가져감(move)소유권을 빌림(borrow)
읽기만x: Tx: &T
수정 가능mut x: Tx: &mut T

mut x: T도 소유권은 똑같이 가져간다. 거기에 “가져온 값을 함수 안에서 수정/재대입도 하겠다”가 추가됐을 뿐이다. 따라서 f(mut x: T)에 값을 넘기면 호출한 쪽에서는 여전히 그 변수를 다시 쓸 수 없다(E0382).

파라미터의 mut은 호출자에게 보이지 않는다

결정적 차이 — 아래 두 함수의 타입 시그니처는 완전히 동일하다.

fn f(x: String)     { }   // 타입: fn(String)
fn f(mut x: String) { }   // 타입: fn(String)  ← 동일!

파라미터 위치의 mut은 타입의 일부가 아니라 함수 내부의 지역 바인딩 속성(패턴의 일부)이다. 호출하는 쪽은 mut 유무를 알 수도, 알 필요도 없다. 반면 &mut는 타입 자체가 다르므로(String vs &mut String) 호출부도 f(&mut x)로 바꿔야 한다.

C와 비교

RustC 대응설명
x: TT x (값 전달)이동본을 받음. 원본은 못 씀
mut x: TT x (값 전달)위와 동일. 함수 안에서 재대입만 추가 허용
x: &Tconst T *x읽기 전용 포인터
x: &mut TT *x쓰기 가능 포인터 (단, 동시에 하나만)

C는 기본이 가변이고 const로 막지만, Rust는 기본이 불변이고 mut으로 푼다 — 방향이 반대다.

mut이 나오는 3가지 자리

let mut glue = Glue::new(MemoryStorage::default());
//  ^^^ (1) 지역 변수를 가변으로 선언
basic(&mut glue);
//    ^^^^ (2) 가변 참조를 만드는 연산
batch(&mut glue);
 
fn basic<T: GStore + GStoreMut + Planner>(glue: &mut Glue<T>) { ... }
//                                              ^^^^ (3) 그 참조를 받는 타입

(1)이 없으면 (2)가 불가능하다 — 불변 변수에서는 가변 참조를 뽑을 수 없다(cannot borrow as mutable). (1)과 (3)은 문법적으로 다른 mut이지만 (1)이 있어야 (2)(3)이 성립하는 관계다.

NOTE

실전 사례 (GlueSQL #2009 테스트 리팩터링) fn basic<T>(mut glue: Glue<T>) → fn basic<T>(glue: &mut Glue<T>)로 고친 적이 있다. 여기서 한 일은 mut을 뗀 게 아니라 &를 붙인 것이다. 전자는 basic(glue) 호출 시 Glue<T>(= 스토리지를 소유한 DB 인스턴스) 소유권이 통째로 넘어가 버려서, 호출부에서 같은 glue로 다른 시나리오를 이어 돌릴 수 없었다. &mut로 바꾸자 소유권은 호출부가 계속 쥐고 각 헬퍼가 차례로 빌려 쓰게 되어, DB 인스턴스 하나로 여러 테스트 시나리오를 순차 실행할 수 있게 됐다(디스크 스토리지의 경우 파일도 하나만 열림). 대신 상태가 누적되므로 각 시나리오가 DROP TABLE IF EXISTS로 자기 앞가림을 해야 한다.

8. &mut self — 메서드 리시버의 소유권은 impl 블록이 정한다

// core/src/glue.rs
impl<T: GStore + GStoreMut + Planner> Glue<T> {
    pub fn execute_with_params<Sql, I, P>(&mut self, sql: Sql, params: I) -> Result<Vec<Payload>>
    where
        Sql: AsRef<str>,
        I: IntoIterator<Item = P>,
        P: IntoParamLiteral,
    {
        // ...
    }
}
  • &mut self는 self: &mut Glue<T>의 축약형 — 이 메서드를 호출하는 동안 Glue<T> 인스턴스를 가변으로 빌린다는 뜻(값을 통째로 옮겨가는 self, 불변으로 빌리는 &self와 구분됨)
  • self가 정확히 어떤 타입인지는 호출부를 보고 추론하는 게 아니라, 메서드가 정의된 impl<T: ...> Glue<T> { ... } 블록이 결정함 — 이 블록 안에 선언된 모든 메서드의 self는 자동으로 Glue<T>(또는 그 참조)
  • C 비교: C에는 메서드가 없으므로 같은 걸 하려면 구조체 포인터를 첫 인자로 직접 넘김 — Payload execute_with_params(Glue *self, const char *sql, ...). Rust의 &mut self는 이 “첫 인자 구조체 포인터”를 컴파일러가 impl 블록 문맥으로부터 자동으로 채워주는 문법 설탕에 가까움
  • 가변 참조는 §4(Rust의 참조 vs C언어의 포인터)의 “1개만 허용” 규칙을 그대로 따름 — &mut self로 빌린 동안에는 그 Glue<T>에 대한 다른 참조(&self든 &mut self든)를 동시에 만들 수 없음

9. Vec<T> — 가변 길이 배열

let mut payloads = Vec::<Payload>::new();
for p in parsed {
    payloads.push(self.execute_stmt(&plan)?);
}
  • Vec<T>는 힙에 할당되는 가변 길이 배열. 내부적으로 { ptr: *mut T, len: usize, cap: usize } 세 필드(64비트 환경에서 24바이트)로 구성 — 포인터, 현재 원소 개수, 할당된 용량
  • .push()로 원소를 추가하다 len == cap이 되면 내부적으로 더 큰 버퍼를 새로 할당하고 기존 값을 복사한 뒤 이전 버퍼를 해제(보통 용량을 2배로 늘림)
  • C 비교: 직접 구현하면 T *ptr; size_t len; size_t cap; 구조체를 만들고, 꽉 찰 때마다 realloc(ptr, new_cap * sizeof(T))을 손으로 호출해야 함. Vec<T>는 이 패턴을 표준 라이브러리가 대신 해주는 것 — 크기 관리, 재할당, 해제(Drop)까지 자동
  • Vec::<Payload>::new()처럼 turbofish(::<T>)로 타입을 명시하거나, 문맥상 추론 가능하면 Vec::new()만 써도 됨

🔗 참고